์›ํ‹ฐ๋“œ ํ”„๋ฆฌ์˜จ๋ณด๋”ฉ 2-1 ๊ณผ์ œํšŒ๊ณ 

@choi2021 ยท November 04, 2022 ยท 19 min read

๐Ÿ“œ ๊ณผ์ œ ์„ค๋ช…

์ด๋ฒˆ ๊ณผ์ œ๋Š” ๊ธฐ์—…๊ณผ์ œ๋กœ ์ฃผ์–ด์ง„ ํ”ผ๊ทธ๋งˆ์˜ ๋””์ž์ธ๊ณผ api๋ฅผ ์ด์šฉํ•ด 2๊ฐ€์ง€ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•ด์•ผํ–ˆ๋‹ค. ํŽ˜์ด์ง€๋Š” ์ฐจ๋Ÿ‰๋ฆฌ์ŠคํŠธ๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” HomeํŽ˜์ด์ง€, ํ•ด๋‹น ์ฐจ๋Ÿ‰์˜ ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” detailํŽ˜์ด์ง€๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์œผ๋ฉฐ, ์ถ”๊ฐ€ ๊ตฌํ˜„์‚ฌํ•ญ์œผ๋กœ ํŽ˜์ด์Šค๋ถ๊ณผ ์นด์นด์˜คํ†ก์— ๊ณต์œ ์‹œ ํ•ด๋‹น ์ด๋ฏธ์ง€์™€ ์ฐจ๋Ÿ‰์ •๋ณด๋“ค์„ ๋ณด์—ฌ์ค„ ์ˆ˜ ์žˆ์–ด์•ผํ•˜๋Š” SEO๊ฐ€ ์žˆ์—ˆ๋‹ค. ๊ณผ์ œ ์ž์ฒด๋Š” ์ €๋ฒˆ ๊ณผ์ œ์™€ ํฌ๊ฒŒ ๋‹ค๋ฅธ ์ ์ด ์—†์–ด์„œ ์ˆ˜์›”ํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ ๊ฐ™์•„, ์ด๋ฒˆ ๊ธฐํšŒ์— ๋ชจ๋‘ ๋‹ค๊ฐ™์ด typescript๋ฅผ ๋„์ž…ํ•ด๋ณด๊ธฐ๋กœ ํ–ˆ๋‹ค.

UseReducer์™€ Context API

์ฒ˜์Œ ๊ณผ์ œ๋ถ€ํ„ฐ ๊ณ„์†ํ•ด์„œ ์‚ฌ์šฉํ•ด์™€์„œ ์กฐ๊ธˆ์€ ์ต์ˆ™ํ•ด์ง„ context API์™€ useReducer๋ฅผ ์ด๋ฒˆ์— ํ•จ๊ป˜ ์‚ฌ์šฉํ•ด๋ณด์•˜๋‹ค. ๋‹ค๋ฅธ ํŒ€์˜ ์ €๋ฒˆ๊ณผ์ œ์˜ ์ฝ”๋“œ๋“ค๊ณผ Velopert๋‹˜์˜ ๊ธ€์„ ์ฐธ๊ณ ํ•ด์„œ ์ฝ”๋“œ๋ฅผ ๊ตฌ์„ฑํ–ˆ๋‹ค.

UseReducer

useReducer๋Š” ์ค‘์ฒฉ๋œ ์ƒํƒœ๋‚˜ ์—ฌ๋Ÿฌ๊ฐ€์ง€ ์ƒํƒœ๋ฅผ ํ•˜๋‚˜์˜ ์˜ค๋ธŒ์ ํŠธ๋กœ ๋ฌถ์–ด์„œ ๊ด€๋ฆฌํ•  ๋•Œ ๋“ฑ, ๋ณต์žกํ•œ ์ƒํƒœ๊ด€๋ฆฌ ๋กœ์ง์„ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” react hook์ด๋‹ค. useReducer์˜ ๋กœ์ง์€ useState์™€ ์œ ์‚ฌํ•˜๊ฒŒ, ์šฐ๋ฆฌ๊ฐ€ ๊ด€๋ฆฌํ•ด์•ผ ํ•  ์ƒํƒœ๊ฐ€ ์žˆ๊ณ , ์ƒํƒœ๋ฅผ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ• ์ง€๋ฅผ ๋‹ด๊ณ  ์žˆ๋Š” action๊ณผ ์ „๋‹ฌ๋ฐ›์€ action์— ๋”ฐ๋ผ ์ฒ˜๋ฆฌํ•ด์ฃผ๋Š” dispatch๊ฐ€ ์žˆ๋‹ค.

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case ActionType.SET_IS_LOADING:
      return {
        ...state,
        isLoading: action.isLoading,
      };
    ...
};

const [state, dispatch] = useReducer(reducer, initialState);
// const [state,setState]=useState()์™€ ์œ ์‚ฌํ•ด

์ด๋ฒˆ ํ”„๋กœ์ ํŠธ์—์„œ reducer๋ฅผ ์‚ฌ์šฉํ•ด๋ณธ ๋ถ€๋ถ„์€ APIํ˜ธ์ถœ์— ๋”ฐ๋ฅธ error, isLoading, data๋ฅผ ํ•˜๋‚˜๋กœ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด, ์ €๋ฒˆ usefetch๋กœ ๋ถ„๋ฆฌํ–ˆ๋˜ customHook์„ useReducer๋กœ ๋Œ€์ฒดํ–ˆ๋‹ค.

type State = {
  isLoading: boolean;
  data: CarType[];
  error: string;
};

type Action =
  | { type: ActionType.SET_DATA; data: CarType[] }
  | { type: ActionType.SET_IS_LOADING; isLoading: boolean }
  | { type: ActionType.SET_ERROR; error: string };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case ActionType.SET_IS_LOADING:
      return {
        ...state,
        isLoading: action.isLoading,
      };
    case ActionType.SET_DATA:
      return {
        ...state,
        data: action.data,
      };
    case ActionType.SET_ERROR:
      return {
        ...state,
        error: action.error,
      };
    default:
      throw new Error('Unknown Action');
  }
};

export const CarsProvider = ({ children }: { children: React.ReactNode }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
		...
};

contextAPI

contextAPI๋ฅผ ๊ธฐ์กด์—์„œ ์‚ฌ์šฉํ•  ๋•Œ๋Š” value์— ์ƒํƒœ์™€ ํ•จ์ˆ˜๋ฅผ ๊ฐ™์ด ๋ณด๋‚ด์ฃผ์—ˆ์ง€๋งŒ ์ด๋ฒˆ์— reducer๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด์„œ ์ƒํƒœ์™€ dispatch ๋‘˜ ์ค‘ ํ•˜๋‚˜๋งŒ ํ•„์š”ํ•  ๋•Œ๊ฐ€ ์žˆ์–ด, stateContext์™€ dispatchContext ๋‘ ๊ฐ€์ง€๋กœ ๋‚˜๋ˆ„์–ด์„œ ๊ตฌ์„ฑํ–ˆ๋‹ค.

type State = {
  isLoading: boolean;
  data: CarType[];
  error: string;
};

type Action =
  | { type: ActionType.SET_DATA; data: CarType[] }
  | { type: ActionType.SET_IS_LOADING; isLoading: boolean }
  | { type: ActionType.SET_ERROR; error: string };

type CarsDistpatch = Dispatch<Action>;

export const CarsStateContext = createContext<State | null>(initialState);
export const CarsDispatchContext = createContext<CarsDistpatch | null>(null);

export const CarsProvider = ({ children }: { children: React.ReactNode }) => {
		...
    return (
    <CarsStateContext.Provider value={state}>
      <CarsDispatchContext.Provider value={dispatch}>
        {children}
      </CarsDispatchContext.Provider>
    </CarsStateContext.Provider>
  );
};

useReducer์™€ contextAPI๋ฅผ ์ด์šฉํ•ด์„œ ๋ณด๋‹ค ๊น”๋”ํ•˜๊ฒŒ ์ƒํƒœ๊ด€๋ฆฌ๋ฅผ ํ•  ์ˆ˜ ์žˆ์—ˆ๊ณ , reducer์—์„œ๋งŒ ์ƒํƒœ๊ด€๋ฆฌ ๋กœ์ง์„ ์ถ”๊ฐ€ํ•˜๋ฉด ๋˜์–ด์„œ ํ™•์žฅ์„ฑ๋„ ์ข‹์€ ์žฅ์ ์„ ๊ฐ–๊ฒŒ ๋˜์—ˆ๋‹ค.

enum ActionEnum {
  SET_IS_LOADING = "SET_IS_LOADING",
  SET_DATA = "SET_DATA",
  SET_ERROR = "SET_ERROR",
}

const App = () => {
  const dispatch = useCarsDispatch()
  const getList = useCallback(async () => {
    dispatch({ type: ActionType.SET_IS_LOADING, isLoading: true })
    try {
      const response = await carsAPI.getCars()
      if (response) {
        dispatch({ type: ActionType.SET_DATA, data: response?.payload })
      }
    } catch (e) {
      if (e instanceof HTTPError) {
        dispatch({ type: ActionType.SET_ERROR, error: e.errorMessage })
      }
      console.error(e)
    } finally {
      dispatch({ type: ActionType.SET_IS_LOADING, isLoading: false })
    }
  }, [dispatch])
  useEffect(() => {
    getList()
  }, [getList])

  return (
    <>
      <Header />
      <Outlet />
    </>
  )
}

export default App

contextAPI๋ฅผ ์ด์šฉํ•œ Filtering

์ด๋ฒˆ ๊ณผ์ œ์—์„œ ์ „์ฒด ์ฐจ๋Ÿ‰์ค‘์—์„œ category๋ฅผ ๋ˆ„๋ฅด๋ฉด ํ•ด๋‹น ์ฐจ๋Ÿ‰์˜ ์ข…๋ฅ˜๋งŒ ๋ณด์—ฌ์ค˜์•ผํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— filtering ๋กœ์ง๋„ ํ•„์š”ํ–ˆ๋‹ค. filtering์„ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๊ธฐ์กด์˜ ์ƒํƒœ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฉด์„œ filterํ•˜๊ณ  ์‹ถ์€ ์ฐจ๋Ÿ‰๋“ค๋งŒ ๋ณด์—ฌ์ค˜์•ผ ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— ๊ธฐ์กด Reducer๋กœ์ง์— ์ถ”๊ฐ€ํ•˜์ง€ ์•Š๊ณ  ๋”ฐ๋กœ caterogryContext๋ฅผ ๋งŒ๋“ค์–ด ๊ด€๋ฆฌํ–ˆ๋‹ค.

//categoryContext.tsx
import { createContext, useState, useMemo } from "react"
import { CategoryType } from "types/CarsInterface"

const initialState = {
  category: "์ „์ฒด",
  setCategory: (category: CategoryType) => {},
}

export const CategoryContext = createContext(initialState)

export const CategoryProvider = ({
  children,
}: {
  children: React.ReactNode
}) => {
  const [category, setCategory] = useState<CategoryType>("์ „์ฒด")
  const value = useMemo(() => ({ category, setCategory }), [category])
  return (
    <CategoryContext.Provider value={value}>
      {children}
    </CategoryContext.Provider>
  )
}

๊ฐ๊ฐ์˜ context API์˜ provider๋Š” ํ•„์š”ํ•œ ๊ณณ์—์„œ ๊ฐ์‹ธ ์ฃผ๋ คํ–ˆ๋‹ค. ์ฐจ๋Ÿ‰ ๋ชฉ๋ก์ด ์žˆ๋‹ค๋ฉด useParam์œผ๋กœ ํ•ด๋‹น ์ฐจ๋Ÿ‰ ์ •๋ณด๋„ ์–ป์„ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋”ฐ๋กœ api๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š๊ณ  ํ•œ๋ฒˆ๋งŒ ํ˜ธ์ถœํ•˜๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•ด Router.jsx์—์„œ carsProvider๋ฅผ ๊ฐ์‹ธ์ฃผ์—ˆ๋‹ค. categoryProvider๋Š” category๋ฅผ updateํ•˜๊ณ  category๋ฅผ ์ด์šฉํ•ด filtering๋œ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›์•„์˜ค๊ธฐ ์œ„ํ•ด categories์™€ carsList๊ฐ€ ์žˆ๋Š” home.tsx์—์„œ ๊ฐ์‹ธ์ฃผ์—ˆ๋‹ค.

//router.tsx
const Router = () => {
  return (
    <CarsProvider>
      <RouterProvider router={router} />
    </CarsProvider>
  )
}

//

const Home = () => {
  return (
    <CategoryProvider>
      <S.Section>
        <Categories />
        <CarList />
      </S.Section>
    </CategoryProvider>
  )
}

export default Home

Custom Hook

์ด๋ฒˆ ๊ณผ์ œ๋ฅผ ํ•˜๋ฉด์„œ ๊ฐ€์žฅ ์‹ ๊ฒฝ์ผ๋˜ ํฌ์ธํŠธ์ค‘ ํ•˜๋‚˜๋Š” ์ปดํฌ๋„ŒํŠธ์˜ ๋‹จ์ˆœํ™”์˜€๋‹ค. ๋ฉ˜ํ† ๋‹˜๊ป˜์„œ ๊ฐ•์˜ ํ•ด์ฃผ์‹  ์ปดํฌ๋„ŒํŠธ์˜ ์ถ”์ƒํ™”์— ๋Œ€ํ•ด ๋งŽ์ด ์ƒ๊ฐํ•˜๋ฉด์„œ ๋˜๋„๋ก์ด๋ฉด Component๊ฐ€ ๋กœ์ง๊ณผ ๊ด€๋ จ๋œ ์ฝ”๋“œ๋ฅผ ๋งŽ์ด ๊ฐ€์ง€๊ณ  ์žˆ์ง€ ์•Š๊ณ , UI ๋ Œ๋”๋ง ๋กœ์ง๋งŒ์„ ๊ฐ€์ง€๊ณ  ์žˆ๊ฒŒ ๋…ธ๋ ฅํ–ˆ๋‹ค. ๊ทธ๋ ‡๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์ค‘๋ณต๋˜๊ฑฐ๋‚˜ ์‚ฌ์šฉ๋˜๋Š” ๋กœ์ง์„ ๋‹ค๋ฅธ ํŒŒ์ผ๋กœ ๋ณด๊ด€ํ•ด์•ผ ํ–ˆ๊ณ , custom hook์„ ์ ๊ทน์ ์œผ๋กœ ์‚ฌ์šฉํ–ˆ๋‹ค.

ํŠนํžˆ home page์˜ carsList ์ปดํฌ๋„ŒํŠธ๋Š” api๋กœ ๋ฐ›์•„์˜จ ์ฐจ๋Ÿ‰๋ฆฌ์ŠคํŠธ๋ฅผ ์นดํ…Œ๊ณ ๋ฆฌ์— ๋งž๊ฒŒ ๋ณด์—ฌ์ค˜์•ผํ–ˆ๋‹ค. ๋‚ด๋ถ€์— carsContext๋กœ๋ถ€ํ„ฐ ๋ฐ›์•„์˜จ ๋ฐ์ดํ„ฐ๋ฅผ filtering์„ ํ•  ์ˆ˜๋„ ์žˆ์ง€๋งŒ ๋กœ์ง์„ ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์— ๋‚จ๊ธฐ๊ณ  ์‹ถ์ง€ ์•Š์•„ customHook์œผ๋กœ ๋งŒ๋“ค์–ด list๋งŒ ๋ฐ›์•„์˜ฌ ์ˆ˜ ์žˆ๊ฒŒ ํ–ˆ๋‹ค. useCarsValue ๋‚ด๋ถ€์—์„œ ํ•„ํ„ฐ๋งํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ปดํฌ๋„ŒํŠธ๋Š” ์—„์ฒญ ๊ฐ„์†Œํ™”๋œ ๊ตฌ์กฐ๋ฅผ ๊ฐ€์งˆ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

//useCars.tsx

export const useCarsState = () => {
  const state = useContext(CarsStateContext)
  if (!state) throw new Error("Can't find State Provider")
  return state
}

export const useCarsDispatch = () => {
  const dispatch = useContext(CarsDispatchContext)
  if (!dispatch) throw new Error("Can't find Dispatch Provider")
  return dispatch
}

export const useCarsValue = () => {
  const state = useCarsState()
  const { category } = useContext(CategoryContext)

  if (!state) throw new Error("Can't find StateProvider")
  if (!category) throw new Error("Can't find CategoryProvider")
  if (category === "์ „์ฒด") return state.data

  const filterd = state?.data.filter(
    car => SegmentEnum[car.attribute.segment] === category
  )
  return filterd
}

//carsList.tsx
import S from "./styles"
import CarItem from "../carItem/CarItem"
import { useCarsState, useCarsValue } from "../../hooks/useCars"

const CarList = () => {
  const { isLoading, error } = useCarsState()
  const data = useCarsValue()
  if (isLoading) {
    return (
      <S.Layout>
        <h3>๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘</h3>
      </S.Layout>
    )
  }

  if (error) {
    return (
      <S.Layout>
        <h3>{error}</h3>
      </S.Layout>
    )
  }

  if (data.length === 0) {
    return (
      <S.Layout>
        <h3>์ฐจ๋Ÿ‰์ด ์—†์Šต๋‹ˆ๋‹ค.</h3>
      </S.Layout>
    )
  }
  return (
    <ul>
      {data.map(car => (
        <CarItem key={car.id} {...car} />
      ))}
    </ul>
  )
}

export default CarList

Typescript

typescript๋Š” ๊ณต๋ถ€๋ฅผ ํ•ด๋„ ์ž˜ ์“ฐ๋Š” ๋ฒ•์ด ๋ฌด์—‡์ธ์ง€ ๊ณ ๋ฏผ์ด ๋งŽ์ด ๋˜์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ๋น ๋ฅธ ๊ฐœ๋ฐœ์„ ์œ„ํ•ด์„œ react๋กœ ํ•œ ํ›„์— ์ฒœ์ฒœํžˆ typescript๋กœ ๋ฐ”๊ฟ”์•ผ์ง€๋ผ๊ณ  ์ƒ๊ฐํ–ˆ์ง€๋งŒ, ๊ณ„์† ๋ฏธ๋ค„์™”์™”๋‹ค. ์ด์ œ๋ถ€ํ„ฐ๋Š” ๊ณ„์†ํ•ด์„œ ์‚ฌ์šฉํ•˜๋ฉด์„œ ๋ถ€๋”ชํžˆ๋ฉด์„œ ๋ฐฐ์›Œ๋‚˜๊ฐ€๊ธฐ๋กœ ๋งˆ์Œ๋จน์—ˆ๋‹ค. ์ด๋ฒˆ ๊ณผ์ œ๋Š” ๋„ˆ๋ฌด ์นœ์ ˆํ•˜๊ฒŒ ๊ณผ์ œ์˜ api๋ฌธ์„œ์— ๋ฐ์ดํ„ฐ๋งˆ๋‹ค type๊นŒ์ง€ ์ž์„ธํžˆ ์•Œ๋ ค์ฃผ๊ธฐ ๋•Œ๋ฌธ์— ๊ผญ ์ ์šฉํ•ด๋ณด๊ณ  ์‹ถ์–ด ์ ๊ทน์ ์œผ๋กœ ํŒ€์— ์ œ์•ˆํ–ˆ๋‹ค.

enum

enum์€ ๋น„์Šทํ•œ ์—ญํ• ์„ ํ•˜๋Š” ๋ณ€์ˆ˜๋“ค์„ ๋ฌถ์Œ์œผ๋กœ ์ตœ๋Œ€ํ•œ string์ด๋‚˜, number์ธ ์ƒํƒœ๋กœ ์˜๋ฏธ๋ฅผ ์•Œ ์ˆ˜ ์—†๋Š” ์ฝ”๋“œ๋ฅผ ๋‚จ๊ธฐ์ง€ ์•Š์œผ๋ ค ์‚ฌ์šฉํ–ˆ๋‹ค. enum์„ ์‚ฌ์šฉํ•  ๋•Œ ์ƒˆ๋กญ๊ฒŒ ์•Œ๊ฒŒ๋œ ์ ์€ object์™€ ๊ฐ™์ด ์‚ฌ์šฉ์ด ๊ฐ€๋Šฅํ•˜๋‹ค๋Š” ์ ์ด์—ˆ๋‹ค.

enum SegmentEnum {
  C = "์†Œํ˜•",
  D = "์ค‘ํ˜•",
  E = "๋Œ€ํ˜•",
  SUV = "SUV",
}

type AttributeType = {
  segment: keyof typeof SegmentEnum
}

segment์˜ type์„ ์ „๋‹ฌํ•  ๋•Œ segmentEnum์ค‘์˜ ํ•˜๋‚˜๋ผ๊ณ  ์•Œ๋ ค์ค„ ๋•Œ keyof typeof๋ฅผ ์ด์šฉํ•  ์ˆ˜ ์žˆ์—ˆ๊ณ  ์ด๋ ‡๊ฒŒ ์ „๋‹ฌํ•ด์ค€ enum์˜ value๊ฐ’์„ ์ฐพ์„ ๋•Œ๋Š” custom Hook์—์„œ key๊ฐ’์„ ์ „๋‹ฌํ•ด์„œ ์ฐพ์„ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

export const useCarsValue = () => {
  //	...
  const filterd = state?.data.filter(
    car => SegmentEnum[car.attribute.segment] === category
  )
  return filterd
}

null/undefined error

null/undefined Error๋Š” ์•„๋งˆ ๊ฐ€์žฅ ์ž์ฃผ ๋งˆ์ฃผํ•˜๋Š” ์—๋Ÿฌ๊ฐ€ ์•„๋‹๊นŒ ์‹ถ๋‹ค. ์กฐ๊ฑด๋ถ€๋กœ ๋ฐ›์•„์˜ฌ ๊ฒฝ์šฐ๋‚˜ null๋กœ ๋ฐ›์•„์˜ฌ ๊ฒฝ์šฐ ํ•ด๋‹น ์˜ค๋ธŒ์ ํŠธ์˜ property๊ฐ€ ์—†์„ ์ˆ˜๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์—๋Ÿฌ๋ฅผ ๋˜์ ธ์ค€๋‹ค.

์—๋Ÿฌ๋ฅผ ๋ง‰๊ธฐ์œ„ํ•ด์„œ๋Š” ํ•ญ์ƒ undefined์ด๋‚˜ null์ผ ๊ฒฝ์šฐ์— ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ๋กœ์ง์„ ์ฒ˜๋ฆฌํ•˜๋ฉด ๊ฐ„๋‹จํ•˜๊ฒŒ ํ•ด๊ฒฐ์ด ๊ฐ€๋Šฅํ•˜๋‹ค.

const Detail = () => {
  const { id } = useParams()
  const car = data.find(item => item.id === +id)

  //	...

  if (!car) {
    return (
      <S.Layout>
        <h3>url์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”</h3>
      </S.Layout>
    )
  }

  const { amount, attribute, startDate, insurance, additionalProducts } = car

  // ..
}

export default Detail

CRA์—์„œ์˜ SEO ๋ฌธ์ œ ํ•ด๊ฒฐ

์ด๋ฒˆ๊ณผ์ œ๋ฅผ ํ•  ๋•Œ CRA์—์„œ ๊ฐ„๋‹จํ•˜๊ฒŒ react-helmet์„ ์ด์šฉํ•˜๋ฉด SEO๋ฅผ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋ž€ ์ƒ๊ฐ์— CRA๋ฅผ ์ด์šฉํ•ด์„œ ์ง„ํ–‰ํ–ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋งˆ์ฃผํ•œ ๋ฌธ์ œ๋“ค์ด ๋งŽ์•˜๋Š”๋ฐ ๋ฌธ์ œํ•ด๊ฒฐ๊ณผ์ •์„ ์ •๋ฆฌํ•ด๋ณด๊ณ ์ž ํ•œ๋‹ค.

SEO ๊ด€์ ์—์„œ์˜ CSR๊ณผ SSR

CSR์€ client์—์„œ ํ™”๋ฉด์„ ๋ Œ๋”๋งํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ, ์„œ๋ฒ„์—์„œ ๋ฐ›์€ ํ•˜๋‚˜์˜ ๋นˆํŽ˜์ด์ง€ index.html์— ๋™์ ์œผ๋กœ html ์š”์†Œ๋ฅผ ๋งŒ๋“œ๋Š” javascript์„ ๋ฐ›์•„ ํ•œ๋ฒˆ์— ๋ณด์—ฌ์ค€๋‹ค. ๊ทธ๋กœ์ธํ•ด ํ™”๋ฉด์ด ๋ณด์ž„๊ณผ ๋™์‹œ์— interactiveํ•œ ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋Š” ์žฅ์ ์ด ์žˆ๋‹ค. ๋‚ด๊ฐ€ ์ž์ฃผ ์‚ฌ์šฉํ•˜๋Š” CRA (create-react-app)๋Š” ๊ฐ„ํŽธํ•˜๊ฒŒ CSR (client-side-rendering)์ด ๊ฐ€๋Šฅํ•œ ํŒจํ‚ค์ง€์ด์ง€๋งŒ CSR์˜ ํŠน์„ฑ์œผ๋กœ SEO์—๋Š” ์ทจ์•ฝํ•œ ๋‹จ์ ์„ ๊ฐ€์ง„๋‹ค.

๊ทธ์— ๋ฐ˜ํ•ด SSR (server-side-rendering) ์€ ์„œ๋ฒ„์—์„œ ์ •์ ์ธ ํŽ˜์ด์ง€๋ฅผ ๋จผ์ € ๋งŒ๋“ค์–ด ๋ Œ๋”๋งํ•ด์ฃผ๊ธฐ ๋•Œ๋ฌธ์— ์ดˆ๊ธฐ ๋ Œ๋”๋ง ์†๋„๊ฐ€ ๋น ๋ฅด๊ณ  ๊ฒ€์ƒ‰์—”์ง„๊ณผ ๊ฐ™์€ ๋ด‡์ด ๋ณด์•˜์„ ๋•Œ ํ•ด๋‹น ๋‚ด์šฉ๋“ค์„ ๋ณผ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— SEO์— ํฐ ์žฅ์ ์„ ๊ฐ–๊ณ  ์žˆ๋‹ค. CSR์— ๋น„ํ•ด ๋จผ์ € ํ™”๋ฉด์ด ๋ณด์ด๊ณ  ์ดํ›„์— javascript๊ฐ€ ์‹คํ–‰๋˜๊ธฐ ๋•Œ๋ฌธ์— ux์ธก๋ฉด์—์„œ๋Š” ๋‹จ์ ์„ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋‹ค. ์ด๋ฒˆ ๊ณผ์ œ๋ฅผ ์œ„ํ•ด์„œ๋Š” SSR์ด ๋” ์ ํ•ฉํ•œ ๋ฐฉ์‹์ด์—ˆ์„ ๊ฒƒ์ด๋ž€ ์ƒ๊ฐ์ด ๋œ๋‹ค.

๊ทธ๋Ÿฌ๋ฉด ์™œ CRA๋กœ ์ง„ํ–‰ํ–ˆ์„๊นŒ?

React์—์„œ SSR์„ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” Next.js๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋œ๋‹ค. ํ•˜์ง€๋งŒ ์•„์ง ์‚ฌ์šฉํ•ด๋ณธ ์ ์ด ์—†๊ณ , typescript์— ์ข€๋” ์ดˆ์ ์„ ๋งž์ถฐ์„œ ๊ณต๋ถ€ํ•˜๋‹ค ๋ณด๋‹ˆ ์‹œ๊ฐ„์ด ๋ถ€์กฑํ•ด ์šฐ์„  ์–ด๋–ป๊ฒŒ๋“  CRA์—์„œ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์„ ์ฐพ์•„์„œ ์ ์šฉํ•ด๋ณด์•˜๋‹ค.

React-Helmet

react-helmet์€ react ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ index.html์˜ head ๋‚ด์šฉ์„ ๋™์ ์œผ๋กœ ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ด๋‹ค. ๊ณผ์ œ์— ํ•„์š”ํ•œ ๋‚ด์šฉ๋“ค์„ ๊ฐ detail ํŽ˜์ด์ง€์˜ ์ •๋ณด์— ๋งž๊ฒŒ head ๋‚ด์šฉ์„ ๋ฐ”๊พธ๊ธฐ ์œ„ํ•ด meta ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋งŒ๋“  ํ›„์— ์ •๋ณด๋ฅผ ๋‹ด์•„์ฃผ์—ˆ๋‹ค. ๊ทธ๊ฒฐ๊ณผ ํŽ˜์ด์ง€์—์„œ ์ž˜๋ฐ”๋€Œ์–ด์žˆ๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.


import { Helmet } from "react-helmet-async"

const Meta = ({ attribute, amount, id }: MetaProps) => {
  const { brand, name, imageUrl } = attribute
  return (
    <Helmet>
      <title>{`${brand} ${name}`}</title>
      <meta name="description" content={`์›” ${amount}์›`} />
      <meta property="og:type" content="website" />
      <link href={imageUrl} />
      <meta property="og:url" content={`${process.env.PUBLIC_URL}/${id}`} />
      <meta name="og:title" content={`${brand} ${name}`} />
      <meta name="og:description" content={`์›” ${amount.toLocaleString()}์›`} />
      <meta property="og:image" content={imageUrl} />
      <meta property="og:image:width" content={IMAGE_SIZE.width.toString()} />
      <meta property="og:image:height" content={IMAGE_SIZE.height.toString()} />
    </Helmet>
  )
}

ํ•˜์ง€๋งŒ ๊ณต์œ ๋ฅผ ํ• ๋•Œ๋Š” ์—ฌ์ „ํžˆ ์ดˆ๊ธฐ index.html์˜ head๋‚ด์šฉ๋งŒ ๋ณด์ด๋Š” ๋ฌธ์ œ์ ์ด ์กด์žฌํ–ˆ๋‹ค. ์ด๋Ÿฌํ•œ ๋ฌธ์ œ์ ์€ head๋‚ด์šฉ์ด javascript๋ฅผ ์ด์šฉํ•ด ๋™์ ์œผ๋กœ ๋ฐ”๋€Œ์ง€๋งŒ ๊ณต์œ ๋ฅผ ํ–ˆ์„ ๋•Œ๋Š” ํ•˜๋‚˜์˜ index.html์˜ ๋‚ด์šฉ์ด ๊ทธ๋Œ€๋กœ ๋ฐ˜์˜๋˜์–ด ์ƒ๊ธด ๋ฌธ์ œ๋กœ ์ƒ๊ฐ๋๋‹ค.

React-snap

react-snap์€ react library๋กœ react-router๋กœ ๋งŒ๋“  ๋™์ ๋ผ์šฐํŒ… ํŽ˜์ด์ง€๋งˆ๋‹ค ์ ํ•ฉํ•œ htmlํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์ฃผ๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ด๋‹ค. index.tsx๋ฅผ hydrate๋ฅผ ์ด์šฉํ•ด client-sideํŽ˜์ด์ง€๋ฅผ static HTML๋กœ ๋ฐ”๊ฟ”์ค€๋‹ค. ๋ฐ”๊ฟ”์ค€ ๊ฒฐ๊ณผ buildํด๋”์— ๋งŒ๋“ค์–ด์งˆ ํŽ˜์ด์ง€๋“ค์˜ ํด๋”์™€ index.html์ด ์ƒ๊ธด ๊ฑธ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

import { hydrate, render } from "react-dom"

const container = document.getElementById("root") as HTMLElement
const root = ReactDOM.createRoot(container)

if (container.hasChildNodes()) {
  ReactDOM.hydrateRoot(
    container,
    <React.StrictMode>
      <ThemeProvider theme={Theme}>
        <GlobalStyle />
        <Router />
      </ThemeProvider>
    </React.StrictMode>
  )
} else {
  root.render(
    <React.StrictMode>
      <ThemeProvider theme={Theme}>
        <GlobalStyle />
        <Router />
      </ThemeProvider>
    </React.StrictMode>
  )
}

๋‘๊ฐ€์ง€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์ด์šฉํ•œ ๋•๋ถ„์— ๋‹คํ–‰ํžˆ ๊ณต์œ ์‹œ ๋‚ด์šฉ๋“ค์ด ๋‹ด์•„ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์—ˆ๊ณ , react-snap์„ ์“ฐ๋ฉด์„œ ์•Œ๊ฒŒ๋œ hydration์ด๋ž€ ๋ฐฉ์‹์ด ์‹ค์ œ๋กœ SSR์„ ์œ„ํ•œ ํ”„๋ ˆ์ž„์›Œํฌ๋“ค Next.js์™€ Gatsby๊ฐ€ ์ด์šฉํ•˜๋Š” ๋ฐฉ์‹์ž„์„ ์•Œ๊ฒŒ ๋˜์—ˆ๋‹ค.

Axios

์ด๋ฒˆ ํ”„๋กœ์ ํŠธ๋ฅผ ํ•˜๋ฉด์„œ ์ƒˆ๋กญ๊ฒŒ ์‹œ๋„ํ•œ ๊ฒƒ์€ fetch๋Œ€์‹  axios๋ฅผ ์‚ฌ์šฉํ–ˆ๋‹ค๋Š” ์ ์ด์—ˆ๋‹ค. fetch๋กœ ์žก์ง€ ๋ชปํ–ˆ๋˜ ์—๋Ÿฌ๋“ค์„ request์™€ response๋กœ ๋‚˜๋ˆ ์„œ ๋ฐ›์•„ ์—๋Ÿฌํ•ธ๋“ค๋ง์ด ๋” ๊ฐ„ํŽธํ–ˆ์œผ๋ฉฐ, ํŒ€์›๋ถ„์ด ์•Œ๋ ค์ฃผ์‹  axios์˜ interceptor๋ฅผ ์ด์šฉํ•˜๋ฉด ๋ณด๋‚ด๊ธฐ ์ „์— ์„ค์ •๋“ค์„ ์ถ”๊ฐ€ํ•  ์ˆ˜๋„ ์žˆ์–ด ๋” ์œ ์šฉํ•œ ๋ถ€๋ถ„์ด ๋งŽ์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ผ ์ƒ๊ฐ๋˜์—ˆ๋‹ค.

์›๋ž˜๋Š” ํ•˜๋‚˜์˜ api๋งŒ ์‚ฌ์šฉํ•  ๋•Œ๋Š” class๋กœ ์‚ฌ์šฉํ•˜๋ฉด ์˜คํžˆ๋ ค ๋” ๋ณต์žกํ•˜๊ฒŒ ๋งŒ๋“ ๋‹ค๊ณ  ์ƒ๊ฐํ•ด์„œ ์‚ฌ์šฉํ•˜์ง€ ์•Š์•˜์ง€๋งŒ class๋กœ ๋ถ„๋ฆฌํ•˜๋ฉด ์ข€ ๋” ์ •๋ฆฌ๊ฐ€ ๋  ์ˆ˜ ์žˆ๊ณ  ํ™•์žฅ์„ฑ์ด ๋†’๋‹ค๋Š” ์žฅ์ ์ด ์žˆ๊ณ , ์ „๋‹ฌ์‹œ instance๋ฅผ ๋งŒ๋“ค์–ด์„œ ์‚ฌ์šฉํ•˜๋Š” ์ ์„ ๋ฐฐ์šธ ์ˆ˜๋„ ์žˆ์—ˆ๋‹ค.

import axios, { AxiosError, AxiosInstance } from "axios"
import { CarType, FuelEnum, SegmentEnum } from "types/CarsInterface"
import createAxiosInstance from "./axiosUtils"
import HTTPError from "../network/httpError"

const BASE_URL = "https://preonboarding.platdev.net/api/cars"

type GetCarsResponse = {
  payload: CarType[]
}

class CarsAPI {
  constructor(private axiosInstance: AxiosInstance) {}

  async getCars(fuelType?: FuelEnum, segment?: SegmentEnum) {
    try {
      const { data } = await this.axiosInstance.get<GetCarsResponse>(BASE_URL, {
        params: {
          fuelType,
          segment,
        },
      })
      return data
    } catch (error) {
      const { response } = error as unknown as AxiosError
      if (response) {
        throw new HTTPError(response?.status, response?.statusText)
      }
      throw new Error("Unknown Error")
    }
  }
}

const carsAPIinstance = createAxiosInstance(BASE_URL)

const carsAPI = new CarsAPI(carsAPIinstance)

export default carsAPI

์—๋Ÿฌํ•ธ๋“ค๋ง

๊ธฐ์กด์— ์‚ฌ์šฉํ–ˆ๋˜ ์—๋Ÿฌํ•ธ๋“ค๋ง์„ ์œ„ํ•œ class๋ฅผ ์ด์šฉํ•ด์„œ ํ•„์š”ํ•œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ์ข€ ๋” ์ดํ•ด๊ฐ€ ์ž˜๋˜๊ฒŒ ์ˆ˜์ •ํ–ˆ๊ณ , typescript์—์„œ ์ œ๊ณตํ•˜๋Š” private, public์„ ์ด์šฉํ•ด ๋ณด๋‹ค ๊ฐ„๋‹จํ•˜๊ฒŒ constructorํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. ์—๋Ÿฌ๋ฅผ ์ƒ์†๋ฐ›๊ธฐ ๋•Œ๋ฌธ์— message๋Š” public์œผ๋กœ ์‚ฌ์šฉํ•ด์ค˜์•ผ ๋œ๋‹ค๋Š” ์ ์„ ์ƒˆ๋กญ๊ฒŒ ์•Œ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

export default class HTTPError extends Error {
  constructor(private statusCode: number, public message: string) {
    super(message)
  }

  get errorMessage() {
    switch (this.statusCode) {
      case 404:
        this.message = "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค. url์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”"
        break
      default:
        throw new Error("Unknown Error")
    }
    return this.message
  }
}

๐Ÿ“ข ๋งˆ์น˜๋ฉฐ

์ด๋ฒˆ ๊ธฐํšŒ๋ฅผ ํ†ตํ•ด์„œ ์™œ ๊ธฐ์—…์ด SEO๋ฅผ ๊ณ ๋ คํ•˜๋Š”์ง€, SEO๋ฅผ ํ•ด๊ฒฐํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ SSR์„ ์ด์šฉํ•ด์•ผํ•˜๋Š” ์ด์œ ๋ฅผ ์ฒด๊ฐํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. ๋ฌด์กฐ๊ฑด SSR์ด ์ข‹๊ณ  ์œ ํ–‰ํ•˜๋‹ˆ๊นŒ ํ•ด์•ผ๋œ๋‹ค๋Š” ์ƒ๊ฐ๋ณด๋‹ค ์–ด๋–ค ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๋‚˜์™”๊ณ  ์ด๊ฒƒ์„ ์–ด๋””์— ์ ์šฉํ•ด์•ผํ•˜๋Š” ์ง€ ์•Œ๊ฒŒ๋˜์—ˆ๊ณ , Next.js๋ฅผ ๊ณต๋ถ€ํ•ด๋ณด๊ณ  ์‹ถ๋‹ค๋Š” ์˜์š•์ด ๋” ์ƒ๊ธฐ๋Š” ๊ณ„๊ธฐ๊ฐ€ ๋˜์—ˆ๋‹ค. ํ•˜๋‚˜๋ฅผ ์™„๋ฒฝํ•˜๊ฒŒ ํ•˜๊ณ  ๋‹ค์Œ์„ ํ•ด์•ผํ•˜์ง€ ์•Š๋‚˜ ์ƒ๊ฐ๋„ ํ•˜์ง€๋งŒ, ์ด๋ฒˆ์— next.js๋ฅผ ๋ชฐ๋ผ์„œ ์ ์šฉ์„ ๋ชปํ–ˆ๋‹ค๋Š” ์•„์‰ฌ์›€์ด ์ƒ๊ฒจ, ๋‚ด๊ฐ€ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋„๊ตฌ๋ฅผ ๋Š˜์ด๋Š” ๊ณผ์ •๋„ ํ•„์š”ํ•˜๋‹ค๋Š” ๊นจ์šฐ์นจ๋„ ์ƒ๊ฒผ๋‹ค.

@choi2021
๋งค์ผ์˜ ์‹œํ–‰์ฐฉ์˜ค๋ฅผ ๊ธฐ๋กํ•˜๋Š” ๊ฐœ๋ฐœ์ผ์ง€์ž…๋‹ˆ๋‹ค.